iT邦幫忙

2025 iThome 鐵人賽

DAY 6
0
Software Development

系統設計一招一式:最基本的功練到爛熟就是殺手鐧,從單體架構到分布式系統的 Lab 實作筆記系列 第 6

Day 6 | 限流器 Lab 實作:搭建 Filter 實作 RequestContext 基礎信息管理

  • 分享至 

  • xImage
  •  

回顧

經過上一篇文章我們了解到在微服務的架構下,統一管理請求基本信息的重要性,在 API Gateway 提取請求信息並妥善管理可以大大降低業務邏輯實作的複雜度,同時利於微服務間對同一個請求信息的傳遞與追蹤,今天我們就要來實作看看,如何在 API Gateway 使用 Filter 在過濾請求時提取請求的基本信息,並整合到既有的 API 限流邏輯中,目的在於實現更細粒度的限流控制,如用戶級別、IP 級別的請求,而不單單只是全域的限流。

實作 Lab 5:搭建 Filter 提取 RequestContext 信息

首先,為什麼要在 Filter 提取請求上下文的基本信息,這是我剛開始接觸到這個概念、開始實作前的第一個疑問,同樣是在到 Controller 之前攔截請求,為什麼不用 Interceptor 或 AOP 呢?後來我得知這是 Filter 跟 Interceptor 之間執行時機作用範圍的差異。上述三者的執行順序為:

Filter → Interceptor → AOP → (Controller)

可以說 Filter 是 Http 請求進到後端系統會遇到的第一個組件,Filter 它是 Servlet 規範的一部分,由 Web 容器來管理,所以當一個請求進來還沒交給 Spring 的 DispatcherServlet 前,就會先經過 Filter。所以重點是 Filter 可以攔截「所有」的請求,但 Interceptor 卻只能攔截 DispatcherServlet 派往 Controller 的請求,所以如果說為何選擇在 Filter 而不是 Interceptor 提取請求上下文,以下是原因:

  1. 更早的執行時機::Filter 在 Servlet 容器層面執行,比 Spring MVC 的 Interceptor 更早,能確保所有進入應用的請求都被處理到
  2. 更廣的覆蓋範圍:如果有直接訪問 Servlet、或者錯誤處理、404 頁面等,Interceptor 攔截不到
  3. 異常處理的完整性:如果 Interceptor 層或 Controller 層拋出異常,Filter 的 finally 塊仍然會執行,並確保 ThreadLocal 一定會被清理,避免記憶體洩漏

至於如何定義 Filter Chain 的順序、如何定義 Filter 要過濾的路徑等細節就不在此展開了。

我們等等除了要創建 RequestContextFilter 外,還有另外兩個在 Filter 裡會用到的 class:

  1. RequestInfo:用於儲存請求上下文基本信息的資料結構,包含基本但重要的各種信息,如請求 ID 用於追蹤、用戶 IP 用於識別、Http Header 等等
  2. RequestContextHelper:(描述後補)

RequestInfo

@Getter
@Setter
public class RequestInfo {

    private String requestId;

    private String ipAddress;

    private Map<String, String> headers;
}

RequestContextHelper

RequestContextHelper 的主要用途是管理請求上下文信息,透過 ThreadLocal 機制為每個請求提供以下功能:

  1. 集中管理:通過 ThreadLocal 存儲 RequestInfo 對象,確保每個線程(即每個請求)擁有獨立的上下文
  2. 全局訪問:任何需要獲取請求信息的組件都可以通過注入 RequestContextHelper 訪問,無需傳遞參數
  3. 資源清理:在請求處理完畢後,負責清理 ThreadLocal 資源,防止內存洩漏
@Component
public class RequestContextHelper {

    private static final ThreadLocal<RequestInfo> REQ_CONTEXT = new ThreadLocal<>();

    public static void setRequestInfo(RequestInfo requestInfo) {
        REQ_CONTEXT.set(requestInfo);
    }

    public static String getClientIp() {
        var info = REQ_CONTEXT.get();
        return info != null ? info.getIpAddress() : null;
    }

    public static void clear() {
        REQ_CONTEXT.remove();
    }
}

RequestContextFilter

接著透過上述兩個 class 實作 RequestContextFilter ,攔截進入系統的 Http 請求,從中提取和管理請求的上下文信息:

  1. 從 HttpServletRequest 中提取請求 ID、Client IP 等基本信息
  2. 創建 RequestInfo,通過 RequestContextHelper 存儲到 ThreadLocal 中,確保每個請求都有獨立的上下文
  3. 請求追蹤:自動為每個請求生成唯一的 requestId,用於全鏈路追蹤和問題排查
  4. IP 解析:解析用戶真實 IP
  5. 資源清理:在 finally 確保請求處理完畢後清理 ThreadLocal 資源,防止內存洩漏
@Component
public class RequestContextFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;

        try {
            var requestInfo = new RequestInfo();
            requestInfo.setRequestId(UUID.randomUUID().toString().replace("-", ""));
            requestInfo.setIpAddress(getClientIp(httpRequest));
            
            var headers = new HashMap<String, String>();
            headers.put("X-Trace-ID", requestInfo.getRequestId());
            headers.put("X-Client-IP", requestInfo.getIpAddress());
            requestInfo.setHeaders(headers);

            RequestContextHelper.setRequestInfo(requestInfo);
            chain.doFilter(request, response);
        } finally {
            RequestContextHelper.clear();
        }
    }

    private String getClientIp(HttpServletRequest request) {
        var xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }

        var xRealIp = request.getHeader("X-Real-IP");
        if (xRealIp != null && !xRealIp.isEmpty()) {
            return xRealIp;
        }
        return request.getRemoteAddr();
    }

}

整合到限流器

在 Filter 搜集好 RequestContext 後,到了 AOP 切面我們就有更多請求的信息可以隨手使用了,有了用戶 IP 我們可以為限流器做更細粒度的控制,例如同一個 IP 在多久時間內不能訪問某某 API 幾次,把從 RequestContext 中提取出來的 IP 融合進 key 的生成策略就能輕易的做到這點:

  1. 在 @RateLimiter 裡加上 boolean byClientIP() default false; 參數:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {

    Algorithm algorithm() default Algorithm.FIXED_WINDOW;

    String key() default "rateLimiter";

    int limit() default 10;

    int window() default 60;

    boolean byClientIP() default false; // 這邊加上一個新的參數

    String fallback() default "";

    Class<?> fallbackClass() default Void.class;

}
  1. 重構一下 key 的生成策略,getContextKey 讓 key 在生成時可以包含 IP:
@Component
public class RateLimiterKeyGenerator {

    private static final String KEY_SEPARATOR = ":";

    public String generateKey(ProceedingJoinPoint joinPoint, RateLimiter rateLimiter) {
        var baseKey = getBaseKey(joinPoint);
        var contextKey = getContextKey(rateLimiter);

        return String.join(KEY_SEPARATOR, baseKey, contextKey);
    }

    private String getBaseKey(ProceedingJoinPoint joinPoint) {
        var className = joinPoint.getTarget().getClass().getSimpleName();
        var methodName = joinPoint.getSignature().getName();
        return String.join(KEY_SEPARATOR, className, methodName);
    }

    private String getContextKey(RateLimiter rateLimiter) {
        var keyParts = new StringBuilder();
        
        if (rateLimiter.byClientIP()) {
            var clientIp = RequestContextHelper.getClientIp();
            keyParts.append("ip:").append(clientIp != null ? clientIp : "unknown");
        }
        
        return keyParts.toString();
    }

}
  1. 在日誌中印出 key 來看看:
throw new BaseException(StatusCode.TOO_MANY_REQUEST,
                    String.format("Rate limit exceeded by key: %s. Max %d requests per %d seconds",
                    key, rateLimiter.limit(), rateLimiter.window()));

測試超過限流次數就會發現這次請求控制的粒度更精細到了用戶 IP 的層級:

key: RateLimiterTestController:getRequestContextString:ip:0:0:0:0:0:0:0:1

{
    "error": "too_many_request",
    "message": "Rate limit exceeded by key: RateLimiterTestController:getRequestContextString:ip:0:0:0:0:0:0:0:1. Max 5 requests per 10 seconds"
}

總結

今天提到了為何選擇在請求到達 Filter 時做 RequestContext 基本信息的提取,是因為它是請求進到後端第一個路過的組件,我們提到了請求從進到後端一直到執行完業務邏輯的順序,提到了 Filter 跟 Interceptor 兩者執行時機與作用範圍的差別,隨後開始實作了 Filter 的具體細節,規劃了用來儲存基本信息的 RequestInfo 資料結構,還有儲存 RequestInfo 的 ThreadLocal 物件,其目的在於為每個請求分配一個獨立的線程做隔離,並且在其後都能在請求生命週期期間做提取,接下來又將 RateLimiter 的鏈路做優化,以整合進 RequestContext 的基本信息,為請求限流做更為精細的控制。

接下來幾天會開始將現有單體架構逐步擴展成分布式環境下可以兼容的系統,會從 Redis 的配置做開頭,可能會把文章的篇幅拆得小一點,但同時盡力確保完整度,目的是希望可以細水長流,持續產出文章。


上一篇
Day 5 | 限流系統實作前導:微服務架構下 RequestContext 基礎管理
下一篇
Day 7 | 限流器實作:Redis 實現分散式限流策略之前導
系列文
系統設計一招一式:最基本的功練到爛熟就是殺手鐧,從單體架構到分布式系統的 Lab 實作筆記14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言